/**
 * Copyright 2009 the original author or authors.
 *
 * <p>Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License at
 *
 * <p>http://www.apache.org/licenses/LICENSE-2.0
 *
 * <p>Unless required by applicable law or agreed to in writing, software distributed under the
 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing permissions and
 * limitations under the License.
 */
package net.javacrumbs.shedlock.provider.jdbc;

import static java.util.Objects.requireNonNull;

import java.sql.Connection;
import java.util.TimeZone;
import javax.sql.DataSource;
import net.javacrumbs.shedlock.provider.sql.DatabaseProduct;
import net.javacrumbs.shedlock.provider.sql.SqlConfiguration;
import net.javacrumbs.shedlock.support.StorageBasedLockProvider;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Lock provided by plain JDBC. It uses a table that contains lock_name and
 * locked_until.
 *
 * <ol>
 * <li>Attempts to insert a new lock record. Since lock name is a primary key,
 * it fails if the record already exists. As an optimization, we keep in-memory
 * track of created lock records.
 * <li>If the insert succeeds (1 inserted row) we have the lock.
 * <li>If the insert failed due to duplicate key or we have skipped the
 * insertion, we will try to update lock record using UPDATE tableName SET
 * lock_until = :lockUntil WHERE name = :lockName AND lock_until &lt;= :now
 * <li>If the update succeeded (1 updated row), we have the lock. If the update
 * failed (0 updated rows) somebody else holds the lock
 * <li>When unlocking, lock_until is set to now.
 * </ol>
 */
public class JdbcLockProvider extends StorageBasedLockProvider {
    public JdbcLockProvider(DataSource datasource) {
        this(datasource, "shedlock");
    }

    public JdbcLockProvider(DataSource datasource, String tableName) {
        this(new Configuration.Builder(datasource).withTableName(tableName).build());
    }

    public JdbcLockProvider(Configuration configuration) {
        super(new JdbcStorageAccessor(configuration));
    }

    public static final class Configuration extends SqlConfiguration {
        private final DataSource dataSource;

        private static final Logger logger = LoggerFactory.getLogger(Configuration.class);

        Configuration(
                DataSource dataSource,
                boolean dbUpperCase,
                @Nullable DatabaseProduct databaseProduct,
                String tableName,
                boolean forceUtcTimeZone,
                ColumnNames columnNames,
                String lockedByValue,
                boolean useDbTime) {

            super(
                    databaseProduct,
                    dbUpperCase,
                    tableName,
                    forceUtcTimeZone ? TimeZone.getTimeZone("UTC") : null,
                    columnNames,
                    lockedByValue,
                    useDbTime);
            this.dataSource = requireNonNull(dataSource, "dataSource can not be null");
        }

        public DataSource getDataSource() {
            return dataSource;
        }

        @Override
        public DatabaseProduct getDatabaseProduct() {
            if (super.getDatabaseProduct() != null) {
                return super.getDatabaseProduct();
            }

            try (Connection connection = dataSource.getConnection()) {
                String jdbcProductName = connection.getMetaData().getDatabaseProductName();
                return DatabaseProduct.matchProductName(jdbcProductName);
            } catch (Exception e) {
                logger.debug("Can not determine database product name {}", e.getMessage());
                return DatabaseProduct.UNKNOWN;
            }
        }

        public static Configuration.Builder builder(DataSource dataSource) {
            return new Configuration.Builder(dataSource);
        }

        public static final class Builder extends SqlConfigurationBuilder<Builder> {
            private final DataSource dataSource;

            private boolean forceUtcTimeZone;

            public Builder(DataSource dataSource) {
                this.dataSource = dataSource;
            }

            public Configuration build() {
                return new Configuration(
                        dataSource,
                        dbUpperCase,
                        databaseProduct,
                        tableName,
                        forceUtcTimeZone,
                        columnNames,
                        lockedByValue,
                        useDbTime);
            }

            /**
             * Enforces UTC times. When the useDbTime() is not set, the timestamps are sent to the DB in the JVM default timezone.
             * If your server is not in UTC and you are not using TIMEZONE WITH TIMESTAMP or an equivalent, the TZ information
             * may be lost. For example in Postgres.
             */
            public Builder forceUtcTimeZone() {
                this.forceUtcTimeZone = true;
                return this;
            }
        }
    }
}
